06 — 相机

Three.js Journey · Bruno Simon · 难度:简单

06 — 相机 课程海报

介绍

我们已经创建过一个 PerspectiveCamera,但正如你在文档中看到的,还有其他类型的相机。

Camera

Camera 类就是我们所说的抽象类(abstract class)。你不应该直接使用它,但可以继承它来获得公共属性和方法。下面这些类中的一部分继承自 Camera 类。

ArrayCamera

ArrayCamera 用于使用多个相机多次渲染你的场景。每个相机会渲染 canvas 的特定区域。你可以把它想象成老式主机上的多人游戏,那时我们必须共享一个分屏。

StereoCamera

StereoCamera 用于通过两个模拟人眼的相机渲染场景,从而创造出我们所说的视差效果,它会骗你的大脑以为其中有深度。你必须配备合适的设备,比如 VR 头显或红蓝眼镜,才能看到效果。

CubeCamera

CubeCamera 用来获取朝每个方向(前、后、左、右、上、下)渲染的结果,以生成立体环境渲染。你可以用它为反射创建环境贴图,或者创建阴影贴图。我们以后会聊这些。

OrthographicCamera

OrthographicCamera 用来创建没有透视的、正交投影的场景渲染。如果你做像 Age of Empire(帝国时代)那样的 RTS 游戏,它会很有用。无论物体离相机多远,它们在屏幕上的大小都是一样的。

PerspectiveCamera

PerspectiveCamera 就是我们用过的那个相机,它模拟真实相机,带有透视效果。

我们会专注于 OrthographicCamera 和 PerspectiveCamera。

PerspectiveCamera

就像我们之前看到的,实例化 PerspectiveCamera 类需要一些参数,但我们之前并没有用到所有可能的参数。加上第三个和第四个参数:

const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 1, 100)

你应该会得到同样的结果,但让我们详细聊聊这些参数。

视场角

第一个参数叫field of view(视场角),对应你的相机视图中垂直方向的角度幅度,以度为单位。如果你用小角度,你会得到一种长焦的效果;如果用广角,你会得到一种鱼眼效果,因为归根结底,相机看到的东西会被拉伸或压缩来适应 canvas。

lesson demo video preview

至于如何选择正确的视场角,你得自己多试试。我通常使用介于 45 和 75 之间的视场角。

纵横比

第二个参数叫aspect ratio(纵横比),对应宽度除以高度。你可能会想,那显然就是 canvas 宽度除以 canvas 高度,Three.js 应该自己算才对,但在你以某些非常特殊的方式使用 Three.js 时,情况并不总是如此。但在我们的情况下,你直接使用 canvas 宽度和 canvas 高度就行。

我建议把这些值保存到一个对象里,因为我们会多次用到:

const sizes = {
    width: 800,
    height: 600
}

近平面和远平面

第三个和第四个参数叫near(近)和far(远),对应相机能看到多近和多远。任何比 near 值更靠近相机、或比 far 值更远离相机的物体或物体的一部分,都不会出现在渲染结果中。

你可以把它想象成那些老式赛车游戏,你能看到远处的树木一下子冒出来。

虽然你可能很想用非常小和非常大的值,比如 0.0001 和 9999999,但你可能最终会遇到一个叫 z-fighting 的 bug,即两个面似乎在争夺谁被渲染在另一个上面。

https://twitter.com/FreyaHolmer/status/799602767081848832

https://twitter.com/Snapman_I_Am/status/800567120765616128

尽量使用合理的数值,只有在需要时才增大它们。在我们的情况下,可以用 0.1 和 100。

OrthographicCamera

虽然我们这门课程剩下的部分不会使用这种相机,但它在特定项目里可能很有用。

OrthographicCamera 与 PerspectiveCamera 的区别在于它没有透视,意味着物体无论离相机多远,大小都一样。

你需要提供的参数和 PerspectiveCamera 差别很大。

不再是一个视场角,你必须提供相机在每个方向(left、right、top、bottom)能看多远。然后就像给 PerspectiveCamera 提供那样,再提供 near 和 far 值。

注释掉 PerspectiveCamera,添加 OrthographicCamera。保留 position 的更新和 lookAt(...) 调用:

const camera = new THREE.OrthographicCamera(- 1, 1, 1, - 1, 0.1, 100)

正如你看到的,这里没有透视,我们立方体的边看起来是平行的。问题在于,我们的立方体看起来不像个立方体了。

这是因为我们给 left、right、top、bottom 提供的值是 1 或 - 1,意思是我们渲染了一块正方形区域,但这个正方形区域会被拉伸去填满矩形的 canvas,而我们的 canvas 并不是正方形。

我们需要使用 canvas 的宽高比(宽度除以高度)。让我们创建一个名为 aspectRatio 的变量(就像 PerspectiveCamera 那样)并把那个比值存进去:

const aspectRatio = sizes.width / sizes.height
const camera = new THREE.OrthographicCamera(- 1 * aspectRatio, 1 * aspectRatio, 1, - 1, 0.1, 100)

结果是渲染区域的宽度大于高度,因为我们的 canvas 宽度大于它的高度。

现在我们有了一个看起来像立方体的立方体。

自定义控制

让我们回到 PerspectiveCamera。注释掉 OrthographicCamera,取消注释 PerspectiveCamera,移动 camera 让它正对立方体,并删除 tick 函数里的 mesh 旋转:

// Camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 1, 1000)

// const aspectRatio = sizes.width / sizes.height
// const camera = new THREE.OrthographicCamera(- 1 * aspectRatio, 1 * aspectRatio, 1, - 1, 0.1, 100)

// camera.position.x = 2
// camera.position.y = 2
camera.position.z = 3
camera.lookAt(mesh.position)
scene.add(camera)

我们现在想做的是用鼠标控制相机。首先,我们要知道鼠标的坐标。我们可以在原生 JavaScript 中用 addEventListener 监听 mousemove 事件来实现。

坐标会作为回调函数的参数出现,即 event.clientX 和 event.clientY:

// Cursor
window.addEventListener('mousemove', (event) =>
{
    console.log(event.clientX, event.clientY)
})

我们可以直接用这些值,但我建议调整一下。调整的意思是,让振幅为 1,而且值既可以为负也可以为正。

如果我们只关注 x 值,那意思就是:

这不是必须的,但拥有这样干净的值很有帮助。

就像 size 变量一样,我们会创建一个带默认 x 和 y 属性的 cursor 变量,然后在 mousemove 回调里更新这些属性:

// Cursor
const cursor = {
    x: 0,
    y: 0
}

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5

    console.log(cursor.x, cursor.y)
})

把 event.clientX 除以 sizes.width 会得到一个介于 0 和 1 之间的值(如果光标保持在 canvas 上方),再减去 0.5 就会得到一个介于 - 0.5 和 0.5 之间的值。

现在你已经把鼠标位置存储到 cursor 对象变量里了,你可以在 tick 函数中更新相机的位置:

const tick = () =>
{
    // ...

    // Update camera
    camera.position.x = cursor.x
    camera.position.y = cursor.y

    // ...
}
lesson demo video preview

如你所见,它能工作,但各轴的运动看起来有点不对。这是因为在 Three.js 中向上时 position.y 轴是正的,但在网页中向下时 clientY 轴才是正的。

你可以在更新 cursor.y 时,在整个公式前面加一个 -(别忘了括号)来简单地反转它:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = - (event.clientY / sizes.height - 0.5)
})
lesson demo video preview

最后,你可以通过乘以 cursor.x 和 cursor.y 来增大振幅,并用 lookAt(...) 方法让相机看向 mesh:

const tick = () =>
{
    // ...

    // Update camera
    camera.position.x = cursor.x * 5
    camera.position.y = cursor.y * 5
    camera.lookAt(mesh.position)

    // ...
}
lesson demo video preview

我们还能更进一步,使用 Math.sin(...) 和 Math.cos(...) 让相机绕 mesh 做完整旋转。

sin 和 cos 组合起来,配合同一个角度使用,能让我们把东西放在一个圆上。要做完整的一圈旋转,那个角度必须有 2 倍 π(读作"pi")的幅度。顺便告诉你,一整圈旋转被称为"tau",但我们在 JavaScript 中访问不到这个值,必须用 π 代替。

在原生 JavaScript 中,你可以用 Math.PI 访问 π 的近似值。

要增大这个圆的半径,你可以直接乘以 Math.sin(...) 和 Math.cos(...) 的结果:

const tick = () =>
{
    // ...

    // Update camera
    camera.position.x = Math.sin(cursor.x * Math.PI * 2) * 2
    camera.position.z = Math.cos(cursor.x * Math.PI * 2) * 2
    camera.position.y = cursor.y * 3
    camera.lookAt(mesh.position)

    // ...
}

tick()
lesson demo video preview

虽然这是控制相机的一个良好开端,但 Three.js 内置了多个名为 controls(控制器)的类,可以帮助你做到同样的事,甚至更多。

内置控制器

如果你在 Three.js 文档里输入"controls",你会看到有很多现成的控制器。我们这门课程剩下的部分只会用到其中一个,但了解它们各自的用途还是很有意思的。

FlyControls

FlyControls 让你像在宇宙飞船里那样移动相机。你可以绕着三个轴旋转、前进和后退。

FirstPersonControls

FirstPersonControls 和 FlyControls 很像,只是上轴固定。你可以把它想象成一只飞鸟的视角,这只鸟不能做桶滚动作。 虽然 FirstPersonControls 包含"FirstPerson"(第一人称),但它的工作方式和 FPS 游戏并不相同。

PointerLockControls

PointerLockControls 使用 pointer lock JavaScript API。这个 API 会隐藏光标,保持光标居中,并持续在 mousemove 事件回调里发送移动信息。有了这个 API,你可以直接在浏览器里创建 FPS 游戏。 虽然如果你想做这种交互,这个类听起来非常有前景,但它只负责在指针锁定时的相机旋转。相机位置和游戏物理还得你自己处理。

OrbitControls

OrbitControls 和我们在上一课做的控制器非常相似。你可以用鼠标左键围绕一个点旋转,用鼠标右键平移,用滚轮放大缩小。

TrackballControls

TrackballControls 和 OrbitControls 相似,但在垂直角度上没有限制。你可以一直旋转,让相机翻跟头,哪怕场景整个倒过来。

TransformControls

TransformControls 和相机没有关系。你可以用它给物体添加一个 gizmo(操作手柄)来移动那个物体。

DragControls

和 TransformControls 一样,DragControls 也和相机没有关系。你可以用它通过拖放来在正对相机的平面上移动物体。

我们只会使用 OrbitControls,但欢迎你去试试其他类。

OrbitControls

让我们注释掉 tick 函数里更新 camera 的那部分。

实例化

首先,我们需要用 OrbitControls 类实例化一个变量。虽然你可能会觉得这里可以用 THREE.OrbitControls,但很遗憾你错了。

OrbitControls 类是那些默认不在 THREE 变量里的类之一。这个决定有助于减小库的体积。而这正是我们的 Vite 模板派上用场的地方。

OrbitControls 类虽然不在 THREE 变量里,但它仍然位于依赖文件夹中。要导入它,你必须提供它在 /node_modules/ 文件夹内的路径,即 /three/examples/jsm/controls/OrbitControls.js:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

现在你可以用 OrbitControls 类(不带 THREE.)实例化一个变量,并确保在创建相机之后再这样做。

为了让它工作,你必须提供相机和页面上负责处理鼠标事件的元素作为参数:

// Controls
const controls = new OrbitControls(camera, canvas)
lesson demo video preview

现在你可以用鼠标左键或右键拖放来移动相机,还可以上下滚动来放大缩小。

这比我们自定义的代码容易得多,而且它自带更多控制。但让我们再深入一点。

Target

默认情况下,相机看向场景中心。我们可以用 target(目标)属性来改变这一点。

这个属性是一个 Vector3,意味着我们可以修改它的 x、y 和 z 属性。

如果我们想让 OrbitControls 默认看向立方体上方,我们只需增大 y 属性:

controls.target.y = 2

但这样并不会直接生效,因为我们需要告诉 OrbitControl 更新自身。我们可以在其后立即调用 update 方法来做到这一点:

controls.target.y = 2
controls.update()

在我们这个例子里这没什么用,所以我们注释掉这部分。

阻尼

如果你读了 OrbitControls 的文档,里面提到了 damping(阻尼)。阻尼会通过添加某种加速度和摩擦公式来使动画更平滑。

要启用阻尼,把 controls 的 enableDamping 属性切换为 true。

为了正确工作,控制器还需要在每一帧都通过调用 controls.update() 来更新。你可以在 tick 函数里做这件事:

// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true

// ...

const tick = () =>
{
    // ...

    // Update controls
    controls.update()

    // ...
}
lesson demo video preview

你会发现现在控制器平滑了许多。

你还可以用许多其他方法和属性来自定义你的控制器,比如旋转速度、缩放速度、缩放限制、角度限制、阻尼强度,以及按键绑定(因为没错,你也可以使用键盘)。

何时使用内置控制器

虽然这些控制器很方便,但它们有限制。如果你太依赖它们,你可能会陷入不得不以出人意料的方式修改这个类的工作方式的地步。

首先,列出你需要这些控制器的所有功能,然后检查你要用的类是否能处理所有这些功能。

如果不行,你就得自己动手了。